""" Hames: a functional testing framework for Google App Engine Python

Docs and examples: http://code.google.com/p/hames/

Copyright 2012 VendAsta Technologies Inc.

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.

Used to discover functional tests in the source.
"""
import os
import logging

from ft import FunctionalTest

from google.appengine.api.app_identity import get_application_id

IS_DEV_APPSERVER = 'development' in os.environ.get('SERVER_SOFTWARE', '').lower()

class TestDiscovery(object):
    """
    Walks the source trees looking for files that end in *_ft.py and then FunctionalTest descendants within.
    """
    def __init__(self):
        """
        Initialize
        """
        # use appengine_config.py to list the appropriate directories to walk
        self.cwd = os.path.abspath(os.path.dirname(__file__))
        self.appid = get_application_id()
        self._rootdir = None
        self._initpy_cache = {}
        self._test_classes = None # TODO: move this cache to a class-level variable

    def _get_root_application_directory(self):
        """
        Returns the root directory for the application.
        """
        if not self._rootdir:
            curdir = self.cwd
            while len(curdir) > 1:
                appyaml = os.path.join(curdir, 'app.yaml')
                if os.path.exists(appyaml):
                    self._rootdir = curdir
                    break
                curdir = curdir[:curdir.rfind('/')]
        return self._rootdir

    def _has_initpy(self, directory):
        """
        Returns True if the directory contains an __init__.py, False otherwise.
        """
        if directory not in self._initpy_cache:
            fname = os.path.join(directory, '__init__.py')
            if os.path.exists(fname):
                self._initpy_cache[directory] = True
            else:
                self._initpy_cache[directory] = False
        return self._initpy_cache[directory]

    @property
    def test_classes(self):
        rootdir = self._get_root_application_directory()
        return self._find_test_classes(directory=rootdir)

    @property
    def collated_test_categories(self):
        """
        Groups the classes together into categories.

        Returns:
            [('', [TestClass1, TestClass2, TestClass3]), ('Category Name', [TestClass4]), ...]
        """
        d = {}
        for tc in self.test_classes:
            catname = tc.category_name()
            if not catname in d:
                d[catname] = []
            d[catname].append(tc)
        # now emit everything in a nice ordering
        result = []
        nocat = d.pop('', [])
        result.append(('', self._sort_test_names(nocat)))
        for cat in sorted(d.keys()):
            result.append((cat, self._sort_test_names(d[cat])))
        return result

    def _sort_test_names(self, testlist):
        return sorted(testlist, key=lambda x: x.screen_name())

    def _find_test_classes(self, directory=None):
        """
        Returns a list of classes that subclass FunctionalTest.
        """
        # return the cached list on production
        if not IS_DEV_APPSERVER and self._test_classes:
            return self._test_classes

        rootappdir = self._get_root_application_directory()
        appdir = directory or rootappdir
        for root, dirs, files in os.walk(appdir):

            # skip .svn folder on localhost
            if '/.svn/' in root:
                continue

            # now process all the python files/modules
            for file_ in files:

                if not file_.endswith('_ft.py'):
                    continue

                # E.g., imagine a src directory, with a lib directory inside of it.
                # The Python path might be "." (src) and "lib". So a file might be:
                #
                #   lib/some_package/some_dir/some_file_ft.py
                #
                # Before considering the Python path, this would yield a package named like the following,
                # which would fail to import:
                #
                #   lib.some_package.some_dir.some_file_ft
                #
                # So, starting from some_file_ft.py, we need to examine the encapsulating directories
                # for __init__.py. When we find a directory without __init__.py, we can adjust the
                # module name appropriately.
                #
                # For the above example, assume that lib does NOT have __init__.py, but some_package
                # and some_dir _do_ have an __init__.py. So we would check for __init__.py file with the
                # following steps:
                #
                #   lib/some_package/some_dir/__init__.py (exists)
                #   lib/some_package/__init__.py          (exists)
                #   lib/__init__.py                       (does not exist)
                #
                # So, we would strip off the "lib" portion of the path to get the following:
                #
                #   some_package/some_dir/some_file_ft.py
                #
                # which would yield the following package name, which does import correctly:
                #
                #   some_package.some_dir.some_file_ft

                # walk up the folders, checking for local __init__.py, stopping once we don't have one
                curdir = root
                while self._has_initpy(curdir):
                    curdir = curdir[:curdir.rfind('/')]
                    if curdir == rootappdir:
                        break # can't read higher than this anyway, will get a security exception of some sort

                # now strip all the curdir stuff off the front of the file_
                module = os.path.join(root, file_) # build an absolute    /path/project/src/lib/package/filename_ft.py
                module = module[len(curdir)+1:]    # remove the curdir    package/filename_ft.py
                module = module[:-3]               # strip off ".py"      package/filename_ft
                module = module.replace('/', '.')  # replace '/' with '.' package.filename_ft

                # attempt to import
                try:
                    logging.info('Importing "%s".', module)
                    mod = __import__(module)
                    if IS_DEV_APPSERVER:
                        # on dev_appserver, we want to force a reload to ensure that we get the most recent
                        reload(mod)
                except ImportError:
                    logging.error('Failed to import "%s". Skipping.', module, exc_info=True)
                    continue

        self._test_classes = self._remove_duplicates(self._get_allsubclasses_for(FunctionalTest))
        # logging.critical('*'*80)
        # logging.critical(self._test_classes)
        return self._test_classes

    def _remove_duplicates(self, testlist):
        """ Remove duplicate classes based on qualified name. We have to do this because reloading modules
        (on dev_appserver) creates new classes that don't hash to the old ones.
        """
        testlist.sort(key=lambda x: x.qualified_name())
        last = testlist[-1]
        for i in range(len(testlist)-2, -1, -1):
            if last.qualified_name() == testlist[i].qualified_name():
                del testlist[i]
            else:
                last = testlist[i]
        return testlist

    def _get_allsubclasses_for(self, klass):
        """
        Recursively gets all the subclasses for a class.
        """
        subclasses = []
        for cls in klass.__subclasses__():
            subclasses.append(cls)
            subclasses.extend(self._get_allsubclasses_for(cls))
        return subclasses
